########################################################################
#  Searx-qt - Lightweight desktop application for SearX.
#  Copyright (C) 2020  CYBERDEViL
#
#  This file is part of Searx-qt.
#
#  Searx-qt is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  Searx-qt is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
########################################################################

from requests.status_codes import _codes as StatusCodes

from PyQt5.QtWidgets import (
    QAbstractItemView,
    QCheckBox,
    QComboBox,
    QDialog,
    QFormLayout,
    QHBoxLayout,
    QHeaderView,
    QLabel,
    QMessageBox,
    QMenu,
    QSpinBox,
    QTableWidget,
    QTableWidgetItem,
    QTabWidget,
    QVBoxLayout,
    QWidget
)

from PyQt5.QtGui import QGuiApplication

from PyQt5.QtCore import Qt, QVariant, QItemSelectionModel

from searxqt.core.requests import ErrorType as RequestsErrorTypes
from searxqt.core.requests import ErrorTypeStr as RequestsErrorTypeStr
from searxqt.core.guard import ConsequenceType, ConsequenceTypeStr

from searxqt.widgets.buttons import Button

from searxqt.translations import _, timeToString, durationMinutesToString


class GuardSettings(QWidget):
    def __init__(self, guard, parent=None):
        QWidget.__init__(self, parent=parent)
        self.__guard = guard

        layout = QVBoxLayout(self)

        # Title label
        layout.addWidget(QLabel("<h2>{0}</h2>".format(_("Guard")), self))

        # Info label
        infoLabel = QLabel(
            _("Guard can put failing instances on the blacklist or on a"
              " timeout based on set rules below."),
            self
        )
        infoLabel.setWordWrap(True)
        layout.addWidget(infoLabel)

        # Enable checkbox
        self.__enableCheck = QCheckBox(_("Enable guard"), self)
        layout.addWidget(self.__enableCheck)

        # Store log checkbox
        self.__storeLogCheck = QCheckBox(_("Store log"), self)
        layout.addWidget(self.__storeLogCheck)
        # Max log period
        self.__maxLogPeriod = QSpinBox(self)
        self.__maxLogPeriod.setSuffix(_(" days"))
        self.__maxLogPeriod.setMinimum(1)
        self.__maxLogPeriod.setMaximum(999)
        self.__maxLogPeriod.setEnabled(False)
        layout.addWidget(self.__maxLogPeriod, 0, Qt.AlignLeft)

        # Tab widget
        self.__tabWidget = QTabWidget(self)
        self.__tabWidget.setTabShape(QTabWidget.Triangular)
        self.__tabWidget.setTabPosition(QTabWidget.West)
        layout.addWidget(self.__tabWidget)

        # Rule editor
        self.__ruleEditor = GuardRuleEditor(guard, self)
        self.__tabWidget.addTab(self.__ruleEditor, _("Rules"))

        # Log viewer
        self.__logViewer = GuardLogViewer(guard, self)
        self.__tabWidget.addTab(self.__logViewer, _("Log"))

        # Initial state
        self.__enableCheck.setChecked(self.__guard.isEnabled())
        self.__storeLogCheck.setChecked(self.__guard.doesStoreLog())
        self.__maxLogPeriod.setEnabled(self.__guard.doesStoreLog())
        self.__maxLogPeriod.setValue(self.__guard.maxLogPeriod())

        # Connections
        self.__enableCheck.stateChanged.connect(self.__enableCheckStateChange)
        self.__storeLogCheck.stateChanged.connect(self.__storeLogStateChange)
        self.__maxLogPeriod.valueChanged.connect(self.__logPeriodChanged)

    def __enableCheckStateChange(self, state):
        self.__guard.setEnabled(bool(state))

        if not state:
            # Guard disabled; ask to clear the log (when any present)
            if self.__guard.log():
                answer = QMessageBox.question(
                    self,
                    _("Clear log?"),
                    _("You've disabled Guard but there are currently logs"
                      " present. Do you want to clear the log?"),
                    QMessageBox.Yes | QMessageBox.No
                )
                if answer == QMessageBox.Yes:
                    self.__logViewer.clearLog()

    def __storeLogStateChange(self, state):
        state = bool(state)
        self.__guard.setStoreLog(state)
        self.__maxLogPeriod.setEnabled(state)

    def __logPeriodChanged(self, value):
        self.__guard.setMaxLogPeriod(value)


class GuardRuleEditor(QWidget):
    def __init__(self, guard, parent=None):
        QWidget.__init__(self, parent=parent)
        self.__guard = guard

        layout = QVBoxLayout(self)

        toolBar = QWidget(self)
        toolBarLayout = QHBoxLayout(toolBar)

        self.__addButton = Button("Add", self)
        self.__editButton = Button("Edit", self)
        self.__editButton.setEnabled(False)
        self.__delButton = Button("Del", self)
        self.__delButton.setEnabled(False)
        self.__upButton = Button("▲", self)
        self.__upButton.setToolTip(_("Move rule up."))
        self.__downButton = Button("▼", self)
        self.__downButton.setToolTip(_("Move rule down."))

        toolBarLayout.addWidget(self.__addButton, 0, Qt.AlignLeft)
        toolBarLayout.addWidget(self.__editButton, 0, Qt.AlignLeft)
        toolBarLayout.addWidget(self.__delButton, 0, Qt.AlignLeft)
        toolBarLayout.addWidget(self.__upButton, 0, Qt.AlignLeft)
        toolBarLayout.addWidget(self.__downButton, 1, Qt.AlignLeft)

        self.__rulesTable = QTableWidget(self)
        self.__rulesTable.setEditTriggers(QAbstractItemView.NoEditTriggers)
        self.__rulesTable.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.__rulesTable.setSelectionMode(QAbstractItemView.SingleSelection)

        layout.addWidget(toolBar)
        layout.addWidget(self.__rulesTable)

        # Fill the table
        self.updateTable()

        # Connections
        self.__addButton.clicked.connect(self.__addRuleDialog)
        self.__editButton.clicked.connect(self.__editSelectedRule)
        self.__delButton.clicked.connect(self.__delSelectedRule)
        self.__upButton.clicked.connect(self.moveSelectedUp)
        self.__downButton.clicked.connect(self.moveSelectedDown)
        self.__rulesTable.itemSelectionChanged.connect(self.__selectionChanged)

    def moveSelected(self, amount=-1):
        """
        @param amount: index offset to move.
        @type amount: int
        """
        # move the selected rule.
        index = self.__rulesTable.currentIndex().row()
        toIndex = index + amount
        self.__guard.moveRule(index, toIndex)

        # update the view.
        self.updateTable()

        # re-select the selected rule on new index.
        item = self.__rulesTable.item(toIndex, 0)
        self.__rulesTable.setCurrentItem(
            item,
            QItemSelectionModel.Rows | QItemSelectionModel.SelectCurrent
        )

    def moveSelectedUp(self):
        self.moveSelected(-1)

    def moveSelectedDown(self):
        self.moveSelected(1)

    def updateTable(self):
        self.__rulesTable.clear()
        # Columns
        # - errorType
        # - amount
        # - period
        # - statusCode
        # - destinationType
        # - duration
        self.__rulesTable.setColumnCount(6)
        self.__rulesTable.setHorizontalHeaderLabels([
            "errorType",
            "amount",
            "period",
            "status",
            "destination",
            "duration"
        ])

        rules = self.__guard.rules()
        self.__rulesTable.setRowCount(len(rules))
        index = 0
        for rule in rules:
            self.updateRuleIndex(
                index,
                rule.condition.errorType,
                rule.condition.amount,
                rule.condition.period,
                rule.condition.status,
                rule.consequence.type,
                rule.consequence.duration
            )
            index += 1

    def __delSelectedRule(self):
        index = self.__rulesTable.currentRow()
        self.__guard.delRule(index)
        self.__rulesTable.removeRow(index)

    def __editSelectedRule(self):
        # Get data of selected rule.
        rowIndex = self.__rulesTable.currentRow()
        errorType = self.__rulesTable.item(rowIndex, 0).data(Qt.UserRole)
        amount = self.__rulesTable.item(rowIndex, 1).data(Qt.UserRole)
        period = self.__rulesTable.item(rowIndex, 2).data(Qt.UserRole)
        statusCode = self.__rulesTable.item(rowIndex, 3).data(Qt.UserRole)
        destinationType = self.__rulesTable.item(rowIndex, 4).data(Qt.UserRole)
        duration = self.__rulesTable.item(rowIndex, 5).data(Qt.UserRole)

        # Open edit dialog with data from the selected rule.
        dialog = GuardEditRuleDialog(
            errorType=errorType,
            amount=amount,
            period=period,
            statusCode=statusCode,
            destinationType=destinationType,
            duration=duration
        )

        if dialog.exec():
            # Accept button is pressed; update the data.
            rule = self.__guard.getRule(rowIndex)
            rule.condition.errorType = dialog.errorType()
            rule.condition.amount = dialog.amount()
            rule.condition.period = dialog.period()
            rule.condition.status = dialog.statusCode()

            rule.consequence.type = dialog.destinationType()
            rule.consequence.duration = dialog.duration()

            self.updateTable()  # Re-generate the table.

    def __selectionChanged(self):
        # When no rule is selected disable the edit and del buttons.
        if self.__rulesTable.currentItem():
            self.__editButton.setEnabled(True)
            self.__delButton.setEnabled(True)

            # Only enable up when the item can go up (index > 0)
            currentIndex = self.__rulesTable.currentIndex().row()
            if currentIndex:
                self.__upButton.setEnabled(True)
            else:
                self.__upButton.setEnabled(False)

            # Only enable down when the item can go down
            if currentIndex < self.__rulesTable.rowCount() - 1:
                self.__downButton.setEnabled(True)
            else:
                self.__downButton.setEnabled(False)
        else:
            self.__editButton.setEnabled(False)
            self.__delButton.setEnabled(False)
            self.__upButton.setEnabled(False)
            self.__downButton.setEnabled(False)

    def updateRuleIndex(
        self, index, errorType, amount,
        period, statusCode, destinationType, duration
    ):
        # errorType
        tableItem = QTableWidgetItem(RequestsErrorTypeStr[errorType])
        tableItem.setData(Qt.UserRole, QVariant(errorType))
        self.__rulesTable.setItem(index, 0, tableItem)
        # amount
        str_ = str(amount) + "x"
        tableItem = QTableWidgetItem(str_)
        tableItem.setData(Qt.UserRole, QVariant(amount))
        self.__rulesTable.setItem(index, 1, tableItem)
        # period
        if period:
            str_ = durationMinutesToString(period)
        else:
            str_ = _("Always")
        tableItem = QTableWidgetItem(str_)
        tableItem.setData(Qt.UserRole, QVariant(period))
        self.__rulesTable.setItem(index, 2, tableItem)
        # statusCode
        if statusCode:
            str_ = str(statusCode)
        else:
            str_ = "-"
        tableItem = QTableWidgetItem(str_)
        tableItem.setData(Qt.UserRole, QVariant(statusCode))
        self.__rulesTable.setItem(index, 3, tableItem)
        # destinationType
        tableItem = QTableWidgetItem(ConsequenceTypeStr[destinationType])
        tableItem.setData(Qt.UserRole, QVariant(destinationType))
        self.__rulesTable.setItem(index, 4, tableItem)
        # duration
        if duration:
            str_ = durationMinutesToString(duration)
        elif destinationType == ConsequenceType.Blacklist:
            str_ = "-"
        else:
            str_ = _("Until restart")
        tableItem = QTableWidgetItem(str_)
        tableItem.setData(Qt.UserRole, QVariant(duration))
        self.__rulesTable.setItem(index, 5, tableItem)

    def __addRuleDialog(self):
        dialog = GuardEditRuleDialog()
        if dialog.exec():
            # errorType
            # amount
            # period
            # statusCode
            # destinationType
            # duration

            rowIndex = self.__rulesTable.rowCount()
            self.__rulesTable.setRowCount(rowIndex + 1)

            # Add view
            self.updateRuleIndex(
                rowIndex,
                dialog.errorType(),
                dialog.amount(),
                dialog.period(),
                dialog.statusCode(),
                dialog.destinationType(),
                dialog.duration()
            )

            # Add the rule itself to Guard
            self.__guard.addRule(
                dialog.errorType(),
                dialog.destinationType(),
                amount=dialog.amount(),
                period=dialog.period(),
                status=dialog.statusCode(),
                duration=dialog.duration()
            )


class GuardEditRuleDialog(QDialog):
    def __init__(
        self,
        errorType=RequestsErrorTypes.WrongStatus,
        amount=0,
        period=0,
        statusCode=None,
        destinationType=ConsequenceType.Timeout,
        duration=0,
        parent=None
    ):
        QDialog.__init__(self, parent=parent)
        self.setWindowTitle(_("Guard rule editor"))
        layout = QFormLayout(self)

        # errorType QComboBox
        # amount QSpinBox
        # period QSpinBox
        # status QComboBox
        # destination QComboBox
        # duration QSpinBox

        # Error type
        self.__errorTypeCombo = QComboBox(self)
        for errType, errTypeStr in RequestsErrorTypeStr.items():
            self.__errorTypeCombo.addItem(errTypeStr, QVariant(errType))
        index = self.__errorTypeCombo.findData(QVariant(errorType))
        self.__errorTypeCombo.setCurrentIndex(index)
        layout.addRow(QLabel(_("Error type") + ":"), self.__errorTypeCombo)

        # Amount of failes to trigger.
        self.__amountSpin = QSpinBox(self)
        self.__amountSpin.setRange(0, 64)
        self.__amountSpin.setValue(amount)
        label = QLabel(_("Amount of failes to trigger") + ":")
        label.setWordWrap(True)
        layout.addRow(label, self.__amountSpin)

        # Timeframe in minutes where the amount of failes have to occur in.
        self.__periodSpin = QSpinBox(self)
        self.__periodSpin.setSuffix(_("min"))
        self.__periodSpin.setRange(0, 6000)
        self.__periodSpin.setValue(period)
        label = QLabel(
            _("Timeframe in minutes where the amount of failes have to occur"
              " in") + ":"
        )
        label.setWordWrap(True)
        layout.addRow(label, self.__periodSpin)

        # Status code
        self.__statusCodeCombo = QComboBox(self)
        self.__statusCodeCombo.addItem(_("All"), QVariant(None))
        for code, statusStr in StatusCodes.items():
            if code >= 400:
                self.__statusCodeCombo.addItem(
                    str(code) + " - " + statusStr[0],
                    QVariant(code)
                )
        index = self.__statusCodeCombo.findData(QVariant(statusCode))
        self.__statusCodeCombo.setCurrentIndex(index)
        label = QLabel(_("Response status code") + ":")
        label.setWordWrap(True)
        layout.addRow(label, self.__statusCodeCombo)

        # Destination
        self.__destinationCombo = QComboBox(self)
        for destType, destStr in ConsequenceTypeStr.items():
            self.__destinationCombo.addItem(destStr, QVariant(destType))
        index = self.__destinationCombo.findData(QVariant(destinationType))
        self.__destinationCombo.setCurrentIndex(index)
        label = QLabel(_("Destination") + ":")
        label.setWordWrap(True)
        layout.addRow(label, self.__destinationCombo)

        # Duration
        self.__durationSpin = QSpinBox(self)
        self.__durationSpin.setSuffix(_("min"))
        self.__durationSpin.setRange(0, 6000)
        self.__durationSpin.setValue(duration)
        label = QLabel(_("Duration of the timeout") + ":")
        label.setWordWrap(True)
        layout.addRow(label, self.__durationSpin)

        # Cancel/Save button
        cancelButton = Button(_("Cancel"), self)
        saveButton = Button(_("Save"), self)
        layout.addRow(cancelButton, saveButton)

        # Initial enable / disable errorType/destination combo
        self.__errorTypeChanged(self.__errorTypeCombo.currentIndex())
        self.__destinationChanged(self.__destinationCombo.currentIndex())

        # Connections
        self.__errorTypeCombo.currentIndexChanged.connect(
            self.__errorTypeChanged
        )
        self.__destinationCombo.currentIndexChanged.connect(
            self.__destinationChanged
        )
        cancelButton.clicked.connect(self.reject)
        saveButton.clicked.connect(self.accept)

    def errorType(self):
        return self.__errorTypeCombo.currentData()

    def amount(self):
        return self.__amountSpin.value()

    def period(self):
        return self.__periodSpin.value()

    def statusCode(self):
        errorType = self.__errorTypeCombo.currentData()
        if errorType == RequestsErrorTypes.WrongStatus:
            return self.__statusCodeCombo.currentData()
        return None

    def destinationType(self):
        return self.__destinationCombo.currentData()

    def duration(self):
        destType = self.__destinationCombo.currentData()
        if destType == ConsequenceType.Timeout:
            return self.__durationSpin.value()
        return 0

    def __errorTypeChanged(self, index):
        # The status code combobox should only be enabled when the WrongStatus
        # errorType is selected.
        errorType = self.__errorTypeCombo.itemData(index)
        if errorType == RequestsErrorTypes.WrongStatus:
            self.__statusCodeCombo.setEnabled(True)
        else:
            self.__statusCodeCombo.setEnabled(False)

    def __destinationChanged(self, index):
        # The duration spinbox should only be enabled when the destinationType
        # is Timeout.
        destType = self.__destinationCombo.itemData(index)
        if destType == ConsequenceType.Timeout:
            self.__durationSpin.setEnabled(True)
        else:
            self.__durationSpin.setEnabled(False)


class GuardLogViewer(QWidget):
    def __init__(self, guard, parent=None):
        QWidget.__init__(self, parent=parent)
        self.__guard = guard

        layout = QVBoxLayout(self)

        layout.addWidget(QLabel("<h2>Log</h2>", self), 0, Qt.AlignTop)

        # Toolbar
        buttonLayout = QHBoxLayout()

        self.__refreshButton = Button("Refresh", self)
        buttonLayout.addWidget(self.__refreshButton, 0, Qt.AlignLeft)

        self.__clearButton = Button("Clear", self)
        buttonLayout.addWidget(self.__clearButton, 1, Qt.AlignLeft)

        layout.addLayout(buttonLayout)

        # Table
        self.__logTable = QTableWidget(self)
        self.__logTable.setEditTriggers(QAbstractItemView.NoEditTriggers)
        self.__logTable.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.__logTable.setContextMenuPolicy(Qt.CustomContextMenu)
        layout.addWidget(self.__logTable)

        self.__refreshButton.clicked.connect(self.updateTable)
        self.__clearButton.clicked.connect(self.__clearLogPressed)
        self.__logTable.customContextMenuRequested.connect(self.__contextMenu)

        self.updateTable()

    def __contextMenu(self, pos):
        selectionModel = self.__logTable.selectionModel()
        if selectionModel.hasSelection():
            indexes = selectionModel.selectedRows(column=1)
            menu = QMenu(self)

            # Copy URL
            copyUrlAction = menu.addAction(_("Copy URL"))
            # Clear log entries for instance(s)
            removeInstanceEntriesAction = menu.addAction(
                _("Clear log for selected instance(s)")
            )
            # Exec the menu
            action = menu.exec_(self.__logTable.mapToGlobal(pos))

            if action == copyUrlAction:
                index = indexes[0]
                item = self.__logTable.item(index.row(), index.column())
                url = item.data(Qt.UserRole)
                clipboard = QGuiApplication.clipboard()
                clipboard.setText(url)

            elif action == removeInstanceEntriesAction:
                removedInstances = []
                for modelIndex in indexes:
                    item = self.__logTable.item(
                        modelIndex.row(), modelIndex.column()
                    )
                    url = item.data(Qt.UserRole)

                    if url in removedInstances:
                        continue

                    self.__guard.clearInstanceLog(url)
                    removedInstances.append(url)

                self.updateTable()

    def __clearLogPressed(self):
        answer = QMessageBox.question(
            self,
            _("Confirm clear log"),
            _("Clear log?"),
            QMessageBox.Yes | QMessageBox.No
        )
        if answer == QMessageBox.Yes:
            self.clearLog()

    def clearLog(self):
        self.__guard.clearLog()
        self.updateTable()

    def updateTable(self):
        # Disable sorting, else it will mess up when already sorted.
        self.__logTable.setSortingEnabled(False)

        self.__logTable.clear()
        self.__logTable.setColumnCount(5)
        self.__logTable.setHorizontalHeaderLabels([
            "datetime",
            "instance",
            "error",
            "status",
            "content"
        ])
        self.__logTable.horizontalHeader().setStretchLastSection(True)
        self.__logTable.horizontalHeader().setSectionResizeMode(
            QHeaderView.ResizeToContents
        )

        # Calc row count
        rowCount = 0
        log = self.__guard.log()
        for url in log:
            for errType in log[url]:
                rowCount += len(log[url][errType])

        self.__logTable.setRowCount(rowCount)

        index = 0
        for url in log:
            for errType in log[url]:
                errCount = len(log[url][errType])
                for logTime, status, content in log[url][errType]:
                    # Datetime
                    tableItem = QTableWidgetItem(
                        timeToString(logTime * 60, fmt="%D %H:%M:%S")
                    )
                    tableItem.setData(Qt.UserRole, QVariant(logTime))
                    self.__logTable.setItem(index, 0, tableItem)

                    # Url
                    tableItem = QTableWidgetItem(url)
                    tableItem.setData(Qt.UserRole, QVariant(url))
                    tableItem.setToolTip(str(errCount))
                    self.__logTable.setItem(index, 1, tableItem)

                    # Error
                    tableItem = QTableWidgetItem(RequestsErrorTypeStr[errType])
                    self.__logTable.setItem(index, 2, tableItem)

                    # Status
                    tableItem = QTableWidgetItem(str(status))
                    if status:
                        tableItem.setToolTip(str(StatusCodes[status]))
                    self.__logTable.setItem(index, 3, tableItem)

                    # Content
                    tableItem = QTableWidgetItem(str(content))
                    self.__logTable.setItem(index, 4, tableItem)

                    index += 1

        # Enable sorting again (restore the set sorting.)
        self.__logTable.setSortingEnabled(True)
